﻿<!DOCTYPE html>
<html>
<head>
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
	<title>Locations on Bing Maps</title>
	<script type="text/javascript" src="ClientGlobalContext.js.aspx"></script>
	<script type="text/javascript" src="dd_XMLParser.js"></script>
	<script type="text/javascript" src="dd_RESTService.js"></script>
	<script type="text/javascript" id="BingMapScript"></script>
	<script type="text/javascript">
		var chartMap = (function (chartMap) {
			// TODO: Add DataDescription options for maxConcurrentRequests, maxRetryCount, and retryDelay from the UI
			var BingMap, allPins = [], config = { geocodeCompleted: 0, concurrentRequestCount: 0, maxConcurrentRequests: 500, maxRetryCount: 5, retryDelay: 100 }, PresentationDescription, DataDescription;
			chartMap.init = function init() {
				retrieveBingMapAPIKey();
				loadChartConfig();
			};
			function loadChartConfig() {
				var parameters = Xrm.Page.context.getQueryStringParameters();
				var dataParam = parseQueryParameters(parameters.data);
				config.entityTypeName = parameters.typename;
				config.chartId = dataParam["visid"];
				config.viewId = dataParam["viewid"];

				dd_CRMService.Retrieve(
					config.chartId, "SavedQueryVisualization", "DataDescription,PresentationDescription", null,
					function (result) {
						PresentationDescription = JSON.parse(result.PresentationDescription);
						DataDescription = JSON.parse(result.DataDescription);
						loadBingMapScript();
						getFetchXml();
					},
					function (error) { }
				);
			}
			function retrieveBingMapAPIKey() {
				dd_CRMService.RetrieveMultiple(
					"Organization", "$select=BingMapsApiKey&$top=1",
					function (results) { config.BingKey = results[0].BingMapsApiKey || "nokey"; },
					function (error) { config.BingKey = "nokey"; }
				);
			}
			function loadBingMapScript() {
				var bingMapScript = document.getElementById("BingMapScript");
				bingMapScript.onload = loadBingMap;
				bingMapScript.src = location.protocol + "//" + DataDescription.BingMapScriptUrl + (location.protocol === "https:" ? "&s=1" : "");
			}
			function loadBingMap() {
				if (typeof Microsoft === "undefined" || !Microsoft.Maps || !Microsoft.Maps.loadModule) {
					setTimeout(loadBingMap, 10);
					return;
				}

				Microsoft.Maps.loadModule('Microsoft.Maps.Themes.BingTheme', { callback: initBingMap });
			}
			function initBingMap() {
				if (!config.BingKey) {
					setTimeout(initBingMap, 10);
					return;
				}

				BingMap = new Microsoft.Maps.Map(document.getElementById("BingMapDiv"), {
					credentials: config.BingKey,
					enableClickableLogo: false,
					showDashboard: false,
					enableSearchLogo: false,
					theme: new Microsoft.Maps.Themes.BingTheme()
				});
				BingMap.setView({ zoom: DataDescription.CenterZoom, center: new Microsoft.Maps.Location(DataDescription.CenterLat, DataDescription.CenterLong) });  // Set default position/zoom level

				Microsoft.Maps.loadModule('Microsoft.Maps.Search', { callback: function () { config.searchManager = new Microsoft.Maps.Search.SearchManager(BingMap); } });
				if (PresentationDescription.MapType === 1) {
					Microsoft.Maps.registerModule("PointBasedClusteringModule", "dd_PointBasedClustering.js");
					Microsoft.Maps.loadModule("PointBasedClusteringModule", { callback: initPointMap });
				}
				else if (PresentationDescription.MapType === 2) {
					Microsoft.Maps.registerModule("HeatMapModule", "dd_HeatMapModule.js");
					Microsoft.Maps.loadModule("HeatMapModule", { callback: initHeatMap });
				}
			}
			function initPointMap() {
				config.clusterLayer = new PointBasedClusteredEntityCollection(
					BingMap, {
						singlePinCallback: createPin,
						clusteredPinCallback: createClusteredPin,
						clusterRadius: PresentationDescription.EnableClustering ? PresentationDescription.ClusterRadius : -1
					}
				);
			}
			function initHeatMap() {
				config.heatLayer = new HeatMapLayer(
					BingMap, [], {
						intensity: PresentationDescription.Intensity,
						radius: PresentationDescription.Radius,
						unit: PresentationDescription.RadiusUnits,
						colourgradient: {
							"0.00": PresentationDescription.Colour1,
							"0.25": PresentationDescription.Colour2,
							"0.50": PresentationDescription.Colour3,
							"0.75": PresentationDescription.Colour4,
							"1.00": PresentationDescription.Colour5
						}
					}
				);

				PresentationDescription.DynamicRadius && Microsoft.Maps.Events.addThrottledHandler(BingMap, 'viewchange', rescaleHeatPoints, 250);
			}
			function rescaleHeatPoints() {
				var MaxZoomRadius = (PresentationDescription.RadiusUnits == "pixels") ? PresentationDescription.Radius : PresentationDescription.Radius / BingMap.getTargetMetersPerPixel();
				config.heatLayer.SetOptions({ radius: BingMap.getTargetZoom() / BingMap.getZoomRange().max * MaxZoomRadius });
			}
			function getFetchXml() {
				if (parent.effectiveFetchXml && Object.prototype.toString.call(parent.effectiveFetchXml) == "[object HTMLDivElement]") {  // Web
					buildFetchXML(parent.effectiveFetchXml.getAttribute("value"), loadData);
				}
				else if (parent.$find("crmGrid")) {  // Outlook
					buildFetchXML(parent.$find("crmGrid").GetParameter("effectiveFetchXml"), loadData);
				}
				else {  // Retrieves the FetchXml using the provided viewId.  This is the only method that is technically supported, but it does not maintain user-filtered results.  This was added to support "Chart-Only" subgrids on Forms and Dashboards, but it also makes a good fallback if the unsupported methods fail.
					dd_CRMService.Retrieve(viewId, "SavedQuery", function (result) { loadData(result.FetchXml); }, function (error) { });
				}
			}
			function loadData(fetchXml) {
				dd_CRMService.RetrieveFetchXml(FetchXml, getPinData, function (error) { });
			}
			function buildFetchXML(fetchXMLString, callback) {
				var result = DataDescription.fetchXML;

				if (!PresentationDescription.ShowAllRecords) {  // If showallrecords is true, then it will already be defaulted to "count=5000" and "page=1"
					result = result.replace('count="250"', fetchXMLString.match(/count=".*?"/));
					result = result.replace('page="1"', fetchXMLString.match(/page=".*?"/));
				}

				result = result.replace("<order/>", fetchXMLString.match(/<order.*?\/>/));
				result = result.replace("<filter/>", fetchXMLString.match(/<filter.*<\/filter>/));  // TODO: add proper support for link-entity filters

				callback(result);
			}
			function getPinData(entityList) {
				config.geocodeTotal = entityList.length;
				for (var i in entityList) {
					var entity = new pinData(entityList[i]);
					if (entity.latitude && entity.longitude) {  // We already have the coordinates
						allPins.push(entity);
						geocodeComplete();
					}
					else if (entity.Address) {  // Must Perform Geocoding
						sendGeocodeRequest(entity);
					}
					else {  // Skip records with no address details
						geocodeComplete();
					}
				}
			}
			function sendGeocodeRequest(pinData) {
				if (!config.searchManager || config.concurrentRequestCount >= config.maxConcurrentRequests) {
					setTimeout(sendGeocodeRequest, 10, pinData);
					return;
				}
				config.concurrentRequestCount += 1;
				config.searchManager.geocode({
					where: pinData.Address,
					count: 1,
					callback: onGeocodeSuccess,
					errorCallback: onGeocodeFail,
					userData: pinData
				});
			}
			function onGeocodeSuccess(result, pinData) {
				if (config.concurrentRequestCount--, (result.results && result.results[0])) {
					pinData.latitude = result.results[0].location.latitude;
					pinData.longitude = result.results[0].location.longitude;
					allPins.push(pinData);

					PresentationDescription.EnableCaching && dd_CRMService.Update(pinData.Id, new CRMRecord(pinData.latitude, pinData.longitude), DataDescription.EntitySchemaName, function () { }, function () { });  // Update the record with discovered coordinates
				}
				geocodeComplete();
			}
			function onGeocodeFail(result) {
				if (config.concurrentRequestCount--, (result.request.userData.retryCount++ < config.maxRetryCount)) {
					setTimeout(sendGeocodeRequest, config.retryDelay, result.request.userData);  // Short delay and retry the failed record.
					return;
				}

				geocodeComplete();
			}
			function geocodeComplete() {
				if (++config.geocodeCompleted >= config.geocodeTotal) {
					if (PresentationDescription.MapType == 1) {
						addPinsToMap();
					}
					else if (PresentationDescription.MapType == 2) {
						generateHeat();
					}

					allPins.length && BingMap.setView({ bounds: Microsoft.Maps.LocationRect.fromLocations(allPins) });
				}
			}
			function addPinsToMap() {
				if (!config.clusterLayer) {
					setTimeout(addPinsToMap, 10);
					return;
				}
				config.clusterLayer.SetData(allPins);
			}
			function createPin(data, pinInfo) {
				return {
					pin: new Microsoft.Maps.Pushpin(pinInfo.center),
					infobox: new Microsoft.Maps.Infobox(
					pinInfo.center, {
						title: data.Name,
						pushpin: this.pin,
						description: data.Address,
						titleClickHandler: function () { Xrm.Utility.openEntityForm(data.EntityType, data.Id); }
					})
				};
			}
			function createClusteredPin(clusterInfo) {
				return {
					Description: clusterInfo.dataIndices.map(function (i) { '<a href="#" onclick="javascript:Xrm.Utility.openEntityForm(\'' + config.entityTypeName + '\',\'' + allPins[i].Id + '\');">' + allPins[i].Name + "</a><br />"; }).join(""),
					pin: new Microsoft.Maps.Pushpin(clusterInfo.center, { text: clusterInfo.dataIndices.length.toString() }),
					infobox: new Microsoft.Maps.Infobox(clusterInfo.center, { title: PresentationDescription.ClusterTitle, pushpin: this.pin, description: Description })
				};
			}
			function generateHeat() {
				if (!config.heatLayer) {
					setTimeout(generateHeat, 10);
					return;
				}

				var Max, Min, NegativeOffset = 0;
				if (PresentationDescription.HeatMapType === 2 && PresentationDescription.IntensityRange === 1) {  // Calculated Range
					var weightArray = allPins.map(function (pin) { return pin.weightValue; });
					if (PresentationDescription.IntensityCalculation == 2) {  // Standard Deviation
						var STDev = stdDev.getStandardDev(weightArray, 2);
						var Mean = stdDev.getAverage(weightArray, 2);

						Max = Mean + (STDev * PresentationDescription.Deviations);
						Min = Mean - (STDev * PresentationDescription.Deviations);
					}
					else {  // Full Range
						Max = Math.max.apply(Math, weightArray);
						Min = Math.min.apply(Math, weightArray);
					}
					if (Min < 0) {  // We now support displaying "negative" values on the heatmap, sooo...  TODO: add a config option to actually display them or not
						NegativeOffset = Math.abs(Min);
						Max += NegativeOffset;
						Min = 0;
					}
				}
				else {  // Fixed range
					Max = PresentationDescription.MaxValue;
					Min = PresentationDescription.MinValue;
				}

				for (var i in allPins) {  // Set specific intensity values for use in Heat Map
					var pinIntensity = ((allPins[i].weightValue + NegativeOffset - Min) / (Max - Min)).toFixed(2);
					pinIntensity = (pinIntensity <= 0) ? 0.01 : pinIntensity;  // Safety net
					pinIntensity = (pinIntensity > 1) ? 1 : pinIntensity;  // Safety net
					allPins[i].intensity = pinIntensity;
				}

				config.heatLayer.SetPoints(allPins);
			}
			function parseQueryParameters(query) {
				var parametersDictionary = [];
				var parameters = query.split('&');
				for (var i in parameters) {
					var keyValuePair = parameters[i].split('=');
					parametersDictionary[unescape(keyValuePair[0])] = unescape(keyValuePair[1]);
				}
				return parametersDictionary;
			}
			var CRMRecord = function CRMRecord(latitude, longitude) {  // Class for records to be updated
				this[DataDescription.LatitudeSchemaName] = latitude;
				this[DataDescription.LongitudeSchemaName] = longitude;
			};
			var pinData = function pinData(entity) {  // Class to store pinData as it gets passed around
				this.Name = entity[DataDescription.NameField];
				this.Id = entity.id;
				this.Address = [entity[DataDescription.AddressField], entity[DataDescription.CityField], entity[DataDescription.StateField], entity[DataDescription.CountryField], entity[DataDescription.PostCodeField]].filter(function (a) { return a != undefined; }).join(", ");
				this.EntityType = entity.logicalName;
				this.latitude = entity[DataDescription.LatitudeField];
				this.longitude = entity[DataDescription.LongitudeField];
				this.weightValue = entity[DataDescription.NumericField] || 0;
				this.retryCount = 0;
			};
			var stdDev = (function (stdDev) {  // Helper for working with standard deviation
				stdDev.getStandardDev = function getStandardDev(numArr, numOfDec) {
					return getNumWithSetDec(Math.sqrt(getVariance(numArr, numOfDec)), numOfDec);
				};
				stdDev.getAverage = function getAverage(numArr, numOfDec) {
					var sum = numArr.reduce(function (a, b) { return a + b; });
					return getNumWithSetDec((sum / numArr.length), numOfDec);
				};
				function getNumWithSetDec(num, numOfDec) {
					var pow10s = Math.pow(10, numOfDec || 0);
					return (numOfDec) ? Math.round(pow10s * num) / pow10s : num;
				}
				function getVariance(numArr, numOfDec) {
					var avg = stdDev.getAverage(numArr, numOfDec);
					var v = numArr.reduce(function (a, b) { return a + Math.pow(b - avg, 2); }, 0);
					return getNumWithSetDec(v / numArr.length, numOfDec);
				}
				return stdDev;
			}(stdDev || {}));
			return chartMap;
		}(chartMap || {}));
	</script>
</head>
<body onload="chartMap.init()" style="overflow: hidden;">
	<div class="MicrosoftMap BingTheme MapTypeId_m medium" id="BingMapDiv"></div>
</body>
</html>
